Skip to content

feat: project named derivatives onto an explicit order - #89

Merged
oberbichler merged 1 commit into
mainfrom
feat/sscalar-projection
Jul 27, 2026
Merged

oberbichler merged 1 commit into
mainfrom
feat/sscalar-projection

Conversation

@oberbichler

Copy link
Copy Markdown
Owner

Builds on #88. Named derivatives were a dead end for anything that wants arrays.

There was no way from an SScalar to a gradient vector or a Hessian matrix — and the module-level helpers made it worse. hj.d and hj.dd check for .g and .hm; a named scalar has neither, so they returned an empty array:

>>> hj.d([x * y, y * z])
array([], shape=(2, 0), dtype=float64)

A wrong answer rather than a missing feature.

Projection

g(names) and hm(names) project onto an explicit, ordered list of names:

x, y, z = hj.SSScalar.variables({"x": 0.5, "y": 0.4, "z": 0.3})

f = x * y

f.g(["x", "y", "z"])        # z is not in f
>>> array([0.4, 0.5, 0. ])
f.hm(["x", "y", "z"]).shape
>>> (3, 3)

That the order comes from the caller is the point. Values carrying different names project onto one shared layout without any of them being padded or remapped first:

values = [x * y, y * z]     # the two carry different names

hj.d(values, names=["x", "y", "z"])
>>> array([[0.4, 0.5, 0. ],
           [0. , 0.3, 0.4]])

That is what an assembly step needs, and it is where named variables earn their keep over indexed ones — no index bookkeeping between the local computation and the global system.

Without names, hj.d/hj.dd now raise TypeError for named scalars. The indexed path is unchanged. variables(dict) builds a set in one call, in the order the dict gives — the order to hand back to g()/hm().

Lookups are batched: one binary search per requested name rather than one per matrix entry, so a k-by-k Hessian costs k log n + k² instead of k² log n.

One gap the mutation check found

Worth recording rather than hiding. The guard that skips unknown names in hm() can be deleted and every assertion still passes — at(k, -1, j) reads before the buffer, and the garbage happened to be exactly the zero the test expects. UBSan aborts on it:

runtime error: addition of unsigned offset to 0x6030000025f0 overflowed to 0x6030000025e0
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior

So the sanitizer job is what pins that guard, not the value comparisons. The other five mutations behaved as expected: ignoring the positions failed 2 assertions, a wrong zero-fill 3, doubled values 11, and the two symmetry-equivalent index swaps correctly did not fail — for a symmetric matrix both mappings are right.

Verification

  • C++ 123 cases / 1931 assertions, Debug and Release with -Werror
  • clang ASan + UBSan 123/123
  • Python 268 tests (9 new)
  • clang-format, ruff clean; just tidy no new findings

Named derivatives were a dead end for anything that wants arrays. There
was no way from an SScalar to a gradient vector or a Hessian matrix, and
hj.d/hj.dd made it worse: they check for .g and .hm, a named scalar has
neither, so they silently returned an empty array. A wrong answer rather
than a missing feature.

g(names) and hm(names) project onto an explicit, ordered list of names.
The order comes from the caller, which is the point: values carrying
different names project onto one shared layout without any of them being
padded or remapped first. That is what an assembly step needs, and it is
where named variables earn their keep over indexed ones -- no index
bookkeeping between the local computation and the global system. A name
the value does not carry reads as zero, the same rule d() and dd()
already follow.

hj.d and hj.dd take the same list via names=, and without it they now
raise TypeError for named scalars instead of returning empty arrays. The
indexed path is unchanged. variables(dict) builds a set in one call, in
the order the dict gives, which is the order to hand back to g() and
hm().

Lookups are batched: one binary search per requested name rather than
one per matrix entry, so a k-by-k Hessian costs k log n + k^2 instead of
k^2 log n.

The mutation check on the new tests found one gap worth recording. The
guard that skips unknown names in hm() can be removed without any
assertion failing -- at(k, -1, j) reads before the buffer, and the
garbage happened to be the zero the test expects. UBSan aborts on it, so
the sanitizer job is what pins that guard, not the value comparisons.
@oberbichler
oberbichler merged commit e1c67a9 into main Jul 27, 2026
17 checks passed
@oberbichler
oberbichler deleted the feat/sscalar-projection branch July 27, 2026 19:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant